import dataclasses as dcl
import itertools
import sys
from abc import ABC, abstractmethod
from datetime import datetime
from numbers import Real
from typing import List, Union

PY_VER = sys.version_info[:2]
if PY_VER < (3, 8):
    print(f'WARNING: Python version {PY_VER[0]}.{PY_VER[1]} is already EOL '
          f'and no longer supported!')

    # Using obsolete Python version, so need to define these functions.
    # These would only work for our specific usage (and might not even work
    # depending on actual version).
    def get_origin(t):
        orig = getattr(t, '__origin__', t)
        return (list if (orig is List) else orig)

    def get_args(t):
        return getattr(t, '__args__', None)

else:
    # These functions were added in 3.8.
    from typing import get_origin, get_args

if PY_VER < (3, 7):
    import dateutil
    dt_fromisoformat = dateutil.parser.isoparse

else:
    # This was added in 3.7.
    dt_fromisoformat = datetime.fromisoformat

from .errors import ZS_ValidationError

_primitive_type_names = [
    'int',
    'float',
    'str',
    'bool',
]

_list_type_names = [f'List[{t}]' for t in _primitive_type_names]

_type_map = {n: eval(n) for n in (_primitive_type_names + _list_type_names)}

def _is_list_type(t):
    return (get_origin(t) is list)

def _is_optional_type(t):
    # Currently Union is only used internally, so checking this way
    # works. If later we allow user defined Union types, this will
    # need to be changed.
    return (get_origin(t) is Union)

def _prim(t):
    return (get_args(t)[0] if _is_list_type(t) else t)

_num_types = [t for t in _type_map.values()
              if (issubclass(_prim(t), Real) and _prim(t) is not bool)]
_list_base_type_map = {t: _prim(t)
                       for t in _type_map.values() if _is_list_type(t)}

_type_map['datetime'] = Union[str, None]
_all_types = list(_type_map.values())

class ValidationSpec(ABC):
    vs_name = None
    allowed_types = None

    def __init__(self, path, n_type_name, n_type, vs_val=None):
        self.path = path
        self.name = path[-1]
        self.n_type_name = n_type_name
        self.n_type = n_type

        if self.allowed_types is not None and n_type not in self.allowed_types:
            msg = (f'Validation "{self.vs_name}" is not allowed '
                   f'for type "{n_type_name}"')
            raise ZS_ValidationError(msg=msg, path=path)

        try:
            self.vs_val = type(self).convert_vs_val(n_type, vs_val)
        except Exception as e:
            raise ZS_ValidationError(msg=f'Validation "{self.vs_name}": '
                                         + str(e), path=path) from None

    @abstractmethod
    def validate(self, value):
        # Raise ZS_ValidationError if error
        pass

    # Override if needed
    # Raise Exception if error
    @classmethod
    def convert_vs_val(cls, n_type, vs_val):
        return n_type(vs_val)

class ListEachVSpec(ValidationSpec):
    # Override
    @classmethod
    def convert_vs_val(cls, n_type, vs_val):
        t = _list_base_type_map.get(n_type, n_type)
        return t(vs_val)

    @abstractmethod
    def validate_one(self, v):
        # Return error message, or None if no error
        return None

    def validate(self, value):
        vlist = value if isinstance(value, list) else [value]
        for v in vlist:
            err = self.validate_one(v)
            if err is not None:
                raise ZS_ValidationError(msg=err, path=self.path)

class NumMinVSpec(ListEachVSpec):
    vs_name = '_min'
    allowed_types = _num_types

    def validate_one(self, v):
        return (f'Value {v} is lower than minimum {self.vs_val}'
                if (v < self.vs_val)
                else None)

class NumMaxVSpec(ListEachVSpec):
    vs_name = '_max'
    allowed_types = _num_types

    def validate_one(self, v):
        return (f'Value {v} is higher than maximum {self.vs_val}'
                if (v > self.vs_val)
                else None)

class ChoicesVSpec(ListEachVSpec):
    vs_name = '_choices'

    # Override
    @classmethod
    def convert_vs_val(cls, n_type, vs_val):
        if not isinstance(vs_val, list):
            raise Exception('Must define a list of choices')

        t = _list_base_type_map.get(n_type, n_type)
        return set(t(v) for v in vs_val)

    def validate_one(self, v):
        return (f'Value {v} is not allowed (choices: {list(self.vs_val)})'
                if (v not in self.vs_val) else None)

_vspec_classes = [
    NumMinVSpec,
    NumMaxVSpec,
    ChoicesVSpec,
]
_vspec_map = {c.vs_name: c for c in _vspec_classes}

class DatetimeTypeVSpec(ValidationSpec):
    @classmethod
    def convert_vs_val(cls, n_type, vs_val):
        # This vspec doesn't use vs_val
        return None

    def validate(self, value):
        if value is None:
            return
        try:
            dt = self.convert(value)
        except ValueError:
            msg = f'Invalid datetime spec "{value}"'
            raise ZS_ValidationError(msg=msg, path=self.path) from None

    @classmethod
    def convert(cls, value):
        return (None if (value is None) else dt_fromisoformat(value))

_type_vspec_map = {
    'datetime': DatetimeTypeVSpec,
}

def _gen_vspecs(node, path, n_type_name, n_type):
    vspecs = []
    for (k, v) in node.items():
        if k not in _vspec_map:
            continue

        vspecs.append(_vspec_map[k](path, n_type_name, n_type, v))

    if n_type_name in _type_vspec_map:
        vspecs.append(_type_vspec_map[n_type_name](path, n_type_name, n_type))

    return vspecs

def check_node(node, path, check_leaf_only=False):
    all_keys = list(node.keys())
    cfglist = node.get('_cfglist', dcl.MISSING)
    is_cfglist = (cfglist is True)  # Treat invalid values as False
    under_keys = [k for k in all_keys if k.startswith('_')]
    is_leaf = (len(under_keys) > (0 if (cfglist is dcl.MISSING) else 1))
    if check_leaf_only:
        return is_leaf

    has_child = (len(under_keys) != len(all_keys))
    child_keys = [k for k in all_keys if (not k.startswith('_'))]
    n_type_name = node.get('_type', None)
    n_type = _type_map.get(n_type_name, None)
    n_def = node.get('_default', dcl.MISSING)
    n_required = bool(node.get('_required', False))

    vspecs = []
    if is_leaf and (n_type is not None) and (path is not None):
        if n_required and n_def is not dcl.MISSING:
            raise ZS_ValidationError(
                    msg='Required node cannot have default', path=path)

        if n_def is dcl.MISSING:
            n_def = (list() if _is_list_type(n_type)
                     else (None if _is_optional_type(n_type)
                           else n_type()))

        vspecs = _gen_vspecs(node, path, n_type_name, n_type)

    conv_func = (None if (n_type_name not in _type_vspec_map)
                 else _type_vspec_map[n_type_name].convert)

    return (is_cfglist, is_leaf, has_child, child_keys,
            n_type, n_def, n_required, conv_func, vspecs)

def is_leaf_node(node_dict):
    return check_node(node_dict, None, check_leaf_only=True)

def validate_fields(self, vspecs):
    d = dcl.asdict(self)
    for vs in itertools.chain.from_iterable(vspecs):
        if vs.name not in d:
            continue
        vs.validate(d[vs.name])

